/*
  File: CtlRelatedNodes.cls
  
  Description:
  
  Controller for the JSON API (relatednodes.page) and the UI page (chattergraph.page).
  
  Author: 
  
  John Hart
  
  Copyright: 
  
  Copyright 2010, iHance, Inc.
  
  Homepage: 
  
  <http://www.ihance.com>
  
  Version: 
  
  1.3.0

  License: GPLv3
 */
public with sharing class CtlRelatedNodes {

	//-------------------------------------------------------------------------------
	// 90% of this page is to support the 'relatednodes' json api page
	// This 10% sets up the root node for the 'chattergraph' UI page
	//-------------------------------------------------------------------------------
	private transient Node rootNode;
	public string getRootNodeJSON() {
		if (rootNode == null) rootNode = getRootNode();
		return rootNode.toJSON();
		}
	
	public string getRootFeedType() {
		if (getArg('feeds') == null) return 'contact';
		return feed2type(getFeeds()[0]);
		}
	public string getRootDir() {
		if (getArg('dir') == null) return 'by';
		return getDir();
		}

	private Node getRootNode() {
		string rootType;
		Id rootId;

		if (getArg('ids') == null) {
			if (getArg('feeds') != null) throw new ArgException('Cannot specify "feeds" without "ids"');
			if (getArg('dir')   != null) throw new ArgException('Cannot specify "dir" without "ids"');
			rootId = UserInfo.getUserId();
			rootType = 'user';
			}
		else {
			rootId = getIds()[0];
			if ( ('' + rootId).startsWith('005')) rootType = 'user';
			else rootType = getRootFeedType(); // note getRootFeedType/getFeeds validates for us
			}

		string nameField = 'Name';
		if (rootType == 'Case') nameField = 'Subject';

		SObject[] got = Database.query('select Id, ' + nameField + ' from ' + rootType + ' where Id = :rootId');
		if (got.size() != 1) throw new ArgException('Could not find "' + rootType + '" for ID <' + rootId + '>');
			
		Node ret = new Node();
		ret.toName = (string)got[0].get(nameField);
		ret.toType = ucFirst(rootType);
		ret.toId = rootId;
		return ret;
		}


	//-------------------------------------------------------------------------------
	// the following args are used by both pages
	//-------------------------------------------------------------------------------
	private transient string[] feeds;
	private string[] getFeeds() {
		if (feeds == null) feeds = parseFeeds(getArg('feeds'));
		return feeds;
		}
	
	
	private transient Id[] ids;
	private Id[] getIds() {
		if (ids == null) ids = parseIds(getArg('ids'));
		return ids;
		}
	
	private transient string dir;
	private string getDir() {
		if (dir == null) dir = parseDir(getArg('dir'));
		return dir;
		}
	
	private transient DateTime timespan;
	private DateTime getTimespan() {
		if (timespan == null) timespan = parseTimespan(getArg('timespan'));
		return timespan;
		}
	
	//-------------------------------------------------------------------------------
	// now, on to the real action for the 'relatednodes' json api page
	//-------------------------------------------------------------------------------
	public string getRelatedNodeJSON() {
		string ret = '[\r\n';
		for (string feed : getFeeds()) {
			if (ret.length() > 3) ret += ',\r\n';
			ret += getNodesJSON(getNodes(feed, getIds(), getDir(), getTimespan()));
			}
		return ret + '\r\n]';
		}
	
	
	//-------------------------------------------------------------------------------
	// helper class
	//-------------------------------------------------------------------------------
	public class Node {
		public integer count;
		public Id fromId;
		public Id toId;
		public string toName;
		public string toType;
		public SObject lastPost;
		
		public Node() {}
		
		public Node(AggregateResult r) {
			this.count  = (integer)r.get('postCt');
			this.fromId = (Id)r.get('nodeFrom');
			this.toId   = (Id)r.get('nodeTo');
			}
		
		// sfdc's "JSONObject" is buggy for JSON output (doesn't escape strings at all, for example)
		// so we do it by hand, here (it would be more elegant, but slower, to have a generic SOBject-to-JSON converter)
		// Note that all IDs are output in their 15 character form, as that's what SFDC's lookup fields
		// return (and thus what we must use for string equality tests etc).
		public string toJSON() {
			return string.format('{ "id": {0}, "name": {1}, "data": { "type": {2}, "fromId": "{3}", "count": {4}, "lastpost": {5} } }'.replace('{ ','\'{\' ').replace(' }',' \'}\'') // ugly, ugly formatting required to use literal { and } in format strings
				,new string[] {
					 jsonString(shortId(toId))
					,jsonString(toName)
					,jsonString(toType)
					,(fromId == null ? '' : shortId(fromId))
					,(count == null ? 'null' : '' + count)
					,(lastPost == null ? 'null' : lastPostJSON())
					}
				);
			}
		
		private string lastPostJSON() {
			FeedPost fp = (FeedPost)lastPost.getSObject('FeedPost');
			return string.format('{ "id": {0}, "type": {1}, "createdDate": {2}, "body": {3}, "linkUrl": {4}, "title": {5}, "contentDescription": {6}, "contentFileName": {7}  }'.replace('{ ','\'{\' ').replace(' }',' \'}\'') // ugly, ugly formatting required to use literal { and } in format strings
				,new string[] {
					 jsonString(shortId((Id)lastPost.get('Id')))
					,jsonString((string)lastPost.get('Type'))
					,'' + ((DateTime)lastPost.get('CreatedDate')).getTime()
					,jsonString(fp.body)
					,jsonString(fp.LinkUrl)
					,jsonString(fp.Title)
					,jsonString(fp.ContentDescription)
					,jsonString(fp.ContentFileName)
					}
				);
			}
		
		private string shortId(Id i) {
			return ('' + i).substring(0,15);
			}
		}
	
	// JSON spec: strings may contain 'any-Unicode-character-except-"-or-\-or-control-character',
	// which must be escaped.  Other characters (tabs, etc) can be escaped but don't have to be.
	// Can't think of a good way to handle control characters in Apex, but I don't think it's possible
	// to store control characters in the DB, so this function is OK for all DB-sourced input
	public static string jsonString(string s) {
		if (s == null) return 'null';
		return '"' + s.replace('\\', '\\\\').replace('"','\\"') + '"';
		}
	
	//-------------------------------------------------------------------------------
	// privates - DB interactions
	//-------------------------------------------------------------------------------
	// returns UNBRACKETED JSON text of nodes
	private static string getNodesJSON(Node[] nodes) {
		string ret = '';
		for (Node i : nodes) {
			if (ret.length() > 0) ret += ',\r\n';
			ret += i.toJson();
			}
		return ret;
		}
	
	private static Node[] getNodes(string feed, Id[] ids, string dir, DateTime edge) {
		AggregateResult[] got = (AggregateResult[])Database.query(getSOQL(feed, ids, dir));

		string nameType = dir == 'to' ? 'User' : feed2type(feed);
		Map<Id, string> names = getNames(nameType, genIdList(got, 'nodeTo'));
	
		Map<Id, SObject> posts = getPosts(feed, genIdList(got, 'lastPostId'));
	
		Node[] ret = new Node[got.size()];
		for (integer i=0; i<got.size(); i++) {
			ret[i] = new Node(got[i]);
			ret[i].toType = nameType;
			ret[i].toName = names.get(ret[i].toId);
			ret[i].lastPost = posts.get((Id)got[i].get('lastPostId'));
			}
	
		return ret;
		}

	private static Map<Id, string> getNames(string objType, Id[] ids) {
		string nameField = nameField(objType);
		Map<Id, string> ret = new Map<Id, string>();
		for (SObject i : Database.query('select ' + nameField + ', Id from ' + objType + ' where Id in :ids'))
			ret.put((Id)i.get('Id'), (string)i.get(nameField));
		return ret;
		}	

	private static Map<Id, SObject> getPosts(string feed, Id[] ids) {
		Map<Id, SObject> ret = new Map<Id, SObject>();
		for (SObject i : Database.query('select Id, Type, CreatedDate, FeedPost.Body, FeedPost.LinkUrl, FeedPost.Title, FeedPost.ContentDescription, FeedPost.ContentFileName from ' + feed + ' where Id in :ids'))
			ret.put((Id)i.get('Id'), i);
		return ret;
		}


	/*
	SOQL to query the given feed for all posts *by* or *to* the given objects (depending on "dir")
	
	eg:
	
	getSOQL('contactfeed', ids, 'to') - treats ids as a set of Contact IDs & returns all Users who have posted to those Contacts
	getSOQL('contactfeed', ids, 'by') - treats ids as a set of User IDs & returns all Contacts to whom those Users have posted to

	getSOQL('userfeed', ids, 'to') - treats ids as a set of User IDs & returns all Users who have posted to <ids>'s feeds
	getSOQL('userfeed', ids, 'by') - treats ids as a set of User IDs & returns all Users to whom <ids> have posted to

	In all cases, the given objIds are included in the result set as 'nodeFrom'
	
	We want to have 100 rows returned per given id.  While this is possible if the ids array is of length 1,
	after that we cannot simply multiply ids.size() by 100 b/c you could end up ignoring ids[1] if ids[0] has
	200 rows that all have higher counts than ids[1] has.  So we ask for 5000 in that case...
	
	*/
	private static string getSOQL(string feed, Id[] ids, string dir) {
		integer ct = ids.size() == 1 ? 100 : 5000;
	
		string majorCol = dir == 'by' ? 'ParentId' : 'CreatedById';
		string minorCol = dir == 'to' ? 'ParentId' : 'CreatedById';
		
		return string.format('select {0} nodeTo, {1} nodeFrom, max(Id) lastPostId, count(Id) postCt from {2} where CreatedDate >= :edge and Type != \'\'UserStatus\'\' and Type != \'\'TrackedChange\'\' and {1} in :ids group by {0}, {1} order by count(Id) desc limit {3}'
			,new string[] { majorCol, minorCol, feed, '' + ct }
			);
		}
	
	
	private static Id[] genIdList(AggregateResult[] got, string field) {
		Id[] ret = new Id[0];
		for (AggregateResult i : got) ret.add((Id)i.get(field));
		return ret;
		}	
	
	private static string feed2type(string feed) {
		return feed.replaceAll('feed$','');
		}
	
	private static string ucFirst(string s) {
		if (s.length() <= 1) return s.toUpperCase();
		return s.substring(0,1).toUpperCase() + s.substring(1,s.length());
		}
	
	// note this hardcoding is fairly hacky.  The other, slower option is to use describe calls & preferentially walk through Name > Subject > etc.
	private static string nameField(string objType) {
		return objType == 'Case' ? 'Subject' : 'Name';
		}
	
	//-------------------------------------------------------------------------------
	// privates - argument parsing
	//-------------------------------------------------------------------------------
	public class ArgException extends Exception{}

	private static string getArg(string k) {
		return System.currentPageReference().getParameters().get(k);
		}
	
	private static string[] parseFeeds(string str) {
		if (str == null) str = '';
		Set<string> objs = Schema.getGlobalDescribe().keySet();
		string[] ret = str.trim().toLowerCase().split('\\s*,\\s*');
		for (string i : ret)
			if (!objs.contains(i)) throw new ArgException('Bad "feeds" value: ' + i);
		return ret;
		}

	private static Id[] parseIds(string str) {
		if (str == null) str = '';
		string[] tmp = str.trim().split('\\s*,\\s*');
		Id[] ret = new Id[tmp.size()];
		for (integer i = 0; i<ret.size(); i++)
			ret[i] = (Id)tmp[i];
		return ret;
		}
	
	private static string parseDir(string str) {
		if (str == null) str = '';
		str = str.trim().toLowerCase();
		if (str != 'to' && str != 'by')
			throw new ArgException('Bad "dir" value: ' + str);
		return str;
		}

	private static DateTime parseTimespan(string str) {
		if (str == null) str = 'd1';
		str = str.trim().toLowerCase();
		if (str == 'd1') return System.now().addDays(-1);
		if (str == 'd7') return System.now().addDays(-7);
		if (str == 'm1') return System.now().addMonths(-1);
		throw new ArgException('Bad "timespan" value: ' + str);
		}	
	
	
	//-------------------------------------------------------------------------------
	// tests - simple
	//-------------------------------------------------------------------------------
	private static testMethod void testArgParsing() {
		T.is(
			new string[] { 'contactfeed', 'accountfeed' },
			parseFeeds(   ' conTACTfeed ,  accountfeed ')
			);
		for (string bad : new string[] { '_nosuchobjectfeed' }) {
			try {
				parseFeeds(bad);
				T.ok(false, 'Expecting exception: ' + bad);
				}
			catch (ArgException expected) {}
			}
		
		T.is(
			new Id[] { '003A000000Ag7JpIAJ', '003A000000Ag7Jk' }, // 18 & 15 character formats
			parseIds( ' 003A000000Ag7JpIAJ ,  003A000000Ag7Jk ')
			);
		
		for (string bad : new string[] { 'a', '003A000000Ag7J', '003A000000Ag7JpI', '003A000000Ag7JpIA'  }) { // 14, 16, 17 character formats...
			try {
				parseIds(bad);
				T.ok(false, 'Expecting exception: ' + bad);
				}
			catch (System.StringException expected) {}
			}
			
		for (string good : new string[] { null, 'd1', 'd7', 'm1' }) {
			parseTimespan(good);
			}
		
		for (string bad : new string[] { '', 'bad timespan' }) {
			try {
				parseTimespan(bad);
				T.ok(false, 'Expecting exception: ' + bad);
				}
			catch (ArgException expected) {}
			}
		}
	
	private static testMethod void testJsonString() {
		T.is('null', jsonString(null));
		T.is('""', jsonString(''));
		T.is('"hey \\" you \\\\ back\r\n\tslash!"', jsonString('hey " you \\ back\r\n\tslash!'));
		}
	
	private static testMethod void testGetSOQL() {
		T.is('select CreatedById nodeTo, ParentId nodeFrom, max(Id) lastPostId, count(Id) postCt from contactfeed where CreatedDate >= :edge and Type != \'UserStatus\' and Type != \'TrackedChange\' and ParentId in :ids group by CreatedById, ParentId order by count(Id) desc limit 100', getSOQL('contactfeed', new Id[1], 'to'));
		T.is('select ParentId nodeTo, CreatedById nodeFrom, max(Id) lastPostId, count(Id) postCt from contactfeed where CreatedDate >= :edge and Type != \'UserStatus\' and Type != \'TrackedChange\' and CreatedById in :ids group by ParentId, CreatedById order by count(Id) desc limit 100', getSOQL('contactfeed', new Id[1], 'by'));
		T.is('select ParentId nodeTo, CreatedById nodeFrom, max(Id) lastPostId, count(Id) postCt from contactfeed where CreatedDate >= :edge and Type != \'UserStatus\' and Type != \'TrackedChange\' and CreatedById in :ids group by ParentId, CreatedById order by count(Id) desc limit 5000', getSOQL('contactfeed', new Id[2], 'by'));
		}
	
	
	//-------------------------------------------------------------------------------
	// "real" tests
	//-------------------------------------------------------------------------------

	// helpers
	private static void is(Node[] want, Node[] got) {
		T.is(want.size(), got.size());
		for (integer i=want.size() - 1; i >= 0; i--) is(want[i], got[i]);
		}
	private static string name(SObject o) {
		return (string)o.get('FirstName') + ' ' + (string)o.get('LastName');
		}
	private static Node n(SObject to, integer count, Id fromId) {
		Node n = new Node();
		n.count  = count ;
		n.fromId = fromId;
		n.toId   = to.Id  ;
		n.toName = name(to);
		return n;
		}
	private static void is(Node n1, Node n2) { // System.assertEquals ain't work for apex objects like this
		T.is(n1.count,  n2.count);
		T.is(n1.fromId, n2.fromId);
		T.is(n1.toId,   n2.toId);
		T.is(n1.toName, n2.toName);
		}	
	

	/* You will recall, from T.insertTestPosts, the following:
	
		System.runAs(T.u1) {
			insert genPosts(new Map<Id, integer> {
				 c1.Id => 2
				,c2.Id => 6
				,l1.Id => 3
				,l2.Id => 4
				});
			}
		System.runAs(T.u2) {
			insert genPosts(new Map<Id, integer> {
				 c2.Id => 3
				,c3.Id => 15
				,l2.Id => 8
				});
	*/
	private static testMethod void testGetNodes() {
		T.setup();
		T.insertTestPosts();
		
		DateTime edge = parseTimespan(null);
		
		is(
			new Node[] {
				n(T.u1, 2, T.c1.Id)
				},
			getNodes('contactfeed', new Id[] { T.c1.Id }, 'to', edge)  // find all users who have posted to c1
			);
		
		is(
			new Node[] {
				n(T.u1, 6, T.c2.Id ),
				n(T.u2, 3, T.c2.Id ),
				n(T.u1, 2, T.c1.Id )
				},
			getNodes('contactfeed', new Id[] { T.c1.Id, T.c2.Id }, 'to', edge)  // find all users who have posted to c1 or c2
			);
		
		is(
			new Node[] {
				n(T.l2, 4, T.u1.Id),
				n(T.l1, 3, T.u1.Id) 
				},
			getNodes('leadfeed', new Id[] { T.u1.Id }, 'by', edge) // find all leads that u1 has posted to
			);

		is(
			new Node[] {
				n(T.l2, 8, T.u2.Id),
				n(T.l2, 4, T.u1.Id),
				n(T.l1, 3, T.u1.Id)
				},
			getNodes('leadfeed', new Id[] { T.u1.Id, T.u2.Id }, 'by', edge) // find all leads that u1 or u2 has posted to
			);
		
		
		// do the first test above in a full round-trip
		System.Test.setCurrentPageReference(new PageReference('/apex/relatednodes?dir=to&feeds=contactfeed&timespan=d7&ids=' + T.c1.Id));
		CtlRelatedNodes ctl = new CtlRelatedNodes();
		T.is(
			'[\r\n' + getNodesJSON(getNodes('contactfeed', new Id[] { T.c1.Id }, 'to', edge)) + '\r\n]',
			ctl.getRelatedNodeJSON()
			);
		}

	}